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:
79
apps/web/src/components/catalog/oem-vote-buttons.tsx
Normal file
79
apps/web/src/components/catalog/oem-vote-buttons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
apps/web/src/components/gamification/leaderboard-podium.tsx
Normal file
96
apps/web/src/components/gamification/leaderboard-podium.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
// Trophy Gamification UI Kit'in leaderboard-podium bileşeni (MIT, ui.trophy.so)
|
||||
// sase'ye uyarlandı: shadcn yerine @sase/ui, cva bağımlılığı söküldü, rank
|
||||
// renk token'ları somut Tailwind renklerine bağlandı, avatar servisi yerine
|
||||
// maskeli adın ilk harfi gösteriliyor (liderlik listesi KVKK-maskeli).
|
||||
import { cn } from "@sase/ui";
|
||||
import { Crown } from "lucide-react";
|
||||
|
||||
export interface PodiumRanking {
|
||||
userId: string;
|
||||
userName: string | null;
|
||||
rank: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const PODIUM_CONFIG = {
|
||||
1: { color: "text-amber-400", bg: "bg-amber-400/50", height: "h-32" },
|
||||
2: { color: "text-zinc-400", bg: "bg-zinc-400/30", height: "h-24" },
|
||||
3: { color: "text-orange-700", bg: "bg-orange-700/40", height: "h-20" },
|
||||
} as const;
|
||||
|
||||
interface LeaderboardPodiumProps {
|
||||
/** İlk 3 sıra (rank 1-3 beklenir) */
|
||||
rankings: PodiumRanking[];
|
||||
showValue?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LeaderboardPodium({
|
||||
rankings,
|
||||
showValue = true,
|
||||
className,
|
||||
}: LeaderboardPodiumProps) {
|
||||
// Kürsü dizilimi: 2. — 1. — 3.
|
||||
const top3 = rankings.slice(0, 3);
|
||||
const podiumOrder = [
|
||||
top3.find((r) => r.rank === 2),
|
||||
top3.find((r) => r.rank === 1),
|
||||
top3.find((r) => r.rank === 3),
|
||||
].filter((r): r is PodiumRanking => Boolean(r));
|
||||
|
||||
if (podiumOrder.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ul
|
||||
className={cn("flex items-end justify-center gap-4", className)}
|
||||
aria-label="İlk 3 sıralama"
|
||||
>
|
||||
{podiumOrder.map((ranking) => {
|
||||
const config = PODIUM_CONFIG[ranking.rank as 1 | 2 | 3];
|
||||
if (!config) return null;
|
||||
const displayName = ranking.userName || "Üye";
|
||||
|
||||
return (
|
||||
<li
|
||||
key={ranking.userId}
|
||||
aria-label={`Sıra ${ranking.rank}: ${displayName}${showValue ? `, ${ranking.value.toLocaleString("tr-TR")} puan` : ""}`}
|
||||
className="flex flex-col items-center"
|
||||
>
|
||||
<div className="relative mb-2" aria-hidden="true">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-14 w-14 items-center justify-center rounded-full text-lg font-semibold",
|
||||
config.bg,
|
||||
)}
|
||||
>
|
||||
{displayName.charAt(0)}
|
||||
</div>
|
||||
<div className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-background shadow-sm">
|
||||
<Crown className={cn("h-4 w-4", config.color)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="max-w-20 truncate text-center text-sm font-medium" title={displayName}>
|
||||
{displayName}
|
||||
</span>
|
||||
|
||||
{showValue && (
|
||||
<span className="text-sm tabular-nums text-muted-foreground">
|
||||
{ranking.value.toLocaleString("tr-TR")}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn("mt-2 w-22 rounded-t-lg", config.height, config.bg)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className={cn("flex h-8 items-center justify-center font-bold", config.color)}>
|
||||
{ranking.rank}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
166
apps/web/src/components/gamification/leaderboard-rankings.tsx
Normal file
166
apps/web/src/components/gamification/leaderboard-rankings.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
// Trophy Gamification UI Kit'in leaderboard-rankings bileşeni (MIT, ui.trophy.so)
|
||||
// sase'ye uyarlandı: @sase/ui importları, Türkçe metinler, avatar yerine
|
||||
// maskeli adın ilk harfi, currentUserId karşılaştırması yerine isCurrentUser
|
||||
// bayrağı (liderlik cevabı kullanıcı id'si sızdırmaz).
|
||||
import { Button, cn } from "@sase/ui";
|
||||
import { ChevronLeft, ChevronRight, Crown } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
export interface LeaderboardRankingItem {
|
||||
userId: string;
|
||||
userName: string | null;
|
||||
rank: number;
|
||||
value: number;
|
||||
byline?: string | null;
|
||||
isCurrentUser?: boolean;
|
||||
}
|
||||
|
||||
interface LeaderboardRankingsProps {
|
||||
rankings: LeaderboardRankingItem[];
|
||||
showPagination?: boolean;
|
||||
defaultPageSize?: 10 | 25 | 50 | 100;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const crownColorMap = {
|
||||
1: "text-amber-400",
|
||||
2: "text-zinc-400",
|
||||
3: "text-orange-700",
|
||||
} as const;
|
||||
|
||||
const pageSizeOptions = [10, 25, 50, 100] as const;
|
||||
|
||||
function formatLeaderboardValue(value: number) {
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}m`;
|
||||
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
||||
return value.toLocaleString("tr-TR");
|
||||
}
|
||||
|
||||
export function LeaderboardRankings({
|
||||
rankings,
|
||||
showPagination = false,
|
||||
defaultPageSize = 25,
|
||||
className,
|
||||
}: LeaderboardRankingsProps) {
|
||||
const [pageSize, setPageSize] = useState<10 | 25 | 50 | 100>(defaultPageSize);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(rankings.length / pageSize));
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage > totalPages) setCurrentPage(totalPages);
|
||||
}, [currentPage, totalPages]);
|
||||
|
||||
const pagedRankings = useMemo(
|
||||
() =>
|
||||
showPagination
|
||||
? rankings.slice((currentPage - 1) * pageSize, currentPage * pageSize)
|
||||
: rankings,
|
||||
[rankings, showPagination, currentPage, pageSize],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full rounded-xl border bg-card", className)}>
|
||||
<ul aria-label="Parça uzmanları sıralaması" className="divide-y divide-border">
|
||||
{pagedRankings.map((ranking) => {
|
||||
const displayName = ranking.userName || "Üye";
|
||||
const showCrown = ranking.rank <= 3;
|
||||
const crownColor = crownColorMap[ranking.rank as 1 | 2 | 3];
|
||||
|
||||
return (
|
||||
<li
|
||||
key={ranking.userId}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2",
|
||||
ranking.isCurrentUser && "rounded-md border-2 border-primary bg-muted",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-12 items-center gap-1">
|
||||
<span className="w-4 text-sm font-semibold tabular-nums">{ranking.rank}</span>
|
||||
{showCrown ? (
|
||||
<Crown className={cn("h-5 w-5", crownColor)} aria-hidden="true" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">
|
||||
{displayName.charAt(0).toLocaleUpperCase("tr-TR")}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{displayName}
|
||||
{ranking.isCurrentUser && (
|
||||
<span className="ml-2 text-xs font-semibold text-primary">(Siz)</span>
|
||||
)}
|
||||
</p>
|
||||
{ranking.byline ? (
|
||||
<p className="truncate text-sm text-muted-foreground">{ranking.byline}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="font-semibold leading-none tabular-nums">
|
||||
{formatLeaderboardValue(ranking.value)}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{showPagination ? (
|
||||
<div className="flex items-center justify-between gap-3 border-t px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="leaderboard-page-size" className="text-sm text-muted-foreground">
|
||||
Göster
|
||||
</label>
|
||||
<select
|
||||
id="leaderboard-page-size"
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value) as 10 | 25 | 50 | 100);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="rounded-md border bg-background px-2 py-1 text-sm text-muted-foreground"
|
||||
>
|
||||
{pageSizeOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Önceki sayfa"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="rounded-md border p-1.5 transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Sayfa {currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Sonraki sayfa"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="rounded-md border p-1.5 transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
apps/web/src/components/gamification/points-badge.tsx
Normal file
44
apps/web/src/components/gamification/points-badge.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
// Trophy Gamification UI Kit'in points-badge bileşeni (MIT, ui.trophy.so)
|
||||
// sase'ye uyarlandı: @sase/ui importları, cva bağımlılığı söküldü.
|
||||
import { cn } from "@sase/ui";
|
||||
import { Sparkle } from "lucide-react";
|
||||
|
||||
interface PointsBadgeProps {
|
||||
name: string;
|
||||
total: number;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
formatValue?: (value: number) => string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PointsBadge({
|
||||
name,
|
||||
total,
|
||||
icon: CustomIcon,
|
||||
formatValue,
|
||||
className,
|
||||
}: PointsBadgeProps) {
|
||||
const Icon = CustomIcon ?? Sparkle;
|
||||
const displayValue = formatValue ? formatValue(total) : total.toLocaleString("tr-TR");
|
||||
|
||||
return (
|
||||
<output
|
||||
aria-label={`${displayValue} ${name}`}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg border bg-card p-4 transition-colors",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10"
|
||||
>
|
||||
<Icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<span className="text-xl font-bold tabular-nums">{displayValue}</span>
|
||||
</div>
|
||||
<span className="truncate text-muted-foreground">{name}</span>
|
||||
</output>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Part } from "@/hooks/use-parts";
|
||||
@@ -6,6 +6,7 @@ import type { Part } from "@/hooks/use-parts";
|
||||
const captureMock = vi.fn();
|
||||
const setSelectedGroupMock = vi.fn();
|
||||
const setHighlightedGroupMock = vi.fn();
|
||||
const toastSuccessMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/posthog", () => ({
|
||||
capture: (...args: unknown[]) => captureMock(...args),
|
||||
@@ -18,6 +19,15 @@ vi.mock("@/lib/api-client", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/toast", () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => toastSuccessMock(...args),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/schema.store", () => ({
|
||||
useSchemaStore: () => ({
|
||||
highlightedGroup: null,
|
||||
@@ -27,6 +37,7 @@ vi.mock("@/stores/schema.store", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { api } from "@/lib/api-client";
|
||||
import { PartsPanel } from "../parts-panel";
|
||||
|
||||
const buildPart = (overrides: Partial<Part> = {}): Part => ({
|
||||
@@ -49,6 +60,8 @@ beforeEach(() => {
|
||||
captureMock.mockClear();
|
||||
setSelectedGroupMock.mockClear();
|
||||
setHighlightedGroupMock.mockClear();
|
||||
toastSuccessMock.mockClear();
|
||||
vi.mocked(api.post).mockReset().mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -148,4 +161,46 @@ describe("PartsPanel", () => {
|
||||
parts_count: parts.length,
|
||||
});
|
||||
});
|
||||
|
||||
it("casts an oem vote from the Uyum column without selecting the row", async () => {
|
||||
const postMock = vi.mocked(api.post);
|
||||
postMock.mockImplementation((path: string) => {
|
||||
if (path === "/oem-votes") {
|
||||
return Promise.resolve({
|
||||
oemCode: "OEM-1",
|
||||
myVote: "compatible",
|
||||
counts: { compatible: 1, incompatible: 0 },
|
||||
pointsAwarded: 3,
|
||||
correct: true,
|
||||
isNew: true,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({});
|
||||
});
|
||||
|
||||
render(<PartsPanel parts={[buildPart()]} vehicleId="v1" categoryId="c1" />);
|
||||
|
||||
const upButton = screen.getByRole("button", { name: "Uyumlu — bu OEM kodu doğru" });
|
||||
fireEvent.click(upButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
"/oem-votes",
|
||||
expect.objectContaining({ oemCode: "OEM-1", vote: "compatible", vehicleId: "v1" }),
|
||||
);
|
||||
});
|
||||
// stopPropagation: oy tıklaması satır (hotspot grubu) seçimini tetiklememeli
|
||||
expect(setSelectedGroupMock).not.toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastSuccessMock).toHaveBeenCalledWith("+3 puan kazandınız!", expect.anything());
|
||||
expect(captureMock).toHaveBeenCalledWith(
|
||||
"oem_vote_cast",
|
||||
expect.objectContaining({ oem_code: "OEM-1", vote: "compatible", points_awarded: 3 }),
|
||||
);
|
||||
});
|
||||
|
||||
// cevap sayaçları butona yansır (1 uyumlu)
|
||||
expect(upButton.textContent).toContain("1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import {
|
||||
type CastOemVoteResult,
|
||||
OemVoteButtons,
|
||||
type OemVoteChoice,
|
||||
type OemVoteSummary,
|
||||
} from "@/components/catalog/oem-vote-buttons";
|
||||
import { ReportCatalogIssueButton } from "@/components/catalog/report-catalog-issue";
|
||||
import type { Part } from "@/hooks/use-parts";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { useSchemaStore } from "@/stores/schema.store";
|
||||
import { Button, Skeleton, cn } from "@sase/ui";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
@@ -66,6 +73,79 @@ export function PartsPanel({
|
||||
};
|
||||
}, [oemCodes]);
|
||||
|
||||
// Topluluk uyumluluk oyları: listedeki kodların özetini tek toplu istekle
|
||||
// çek (lookup dto sınırı 400 kod). Sınır dışında kalan nadir kodlar 0
|
||||
// sayaçla başlar; oy verilince cast cevabı gerçek sayıları getirir.
|
||||
const [voteSummaries, setVoteSummaries] = useState<Record<string, OemVoteSummary>>({});
|
||||
const [votePendingCode, setVotePendingCode] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (oemCodes.length === 0) {
|
||||
setVoteSummaries({});
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
api
|
||||
.post<{ votes: Record<string, OemVoteSummary> }>("/oem-votes/lookup", {
|
||||
codes: oemCodes.slice(0, 400),
|
||||
})
|
||||
.then((res) => {
|
||||
if (!cancelled) setVoteSummaries(res?.votes ?? {});
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setVoteSummaries({});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [oemCodes]);
|
||||
|
||||
const castVote = useCallback(
|
||||
async (part: Part, vote: OemVoteChoice) => {
|
||||
setVotePendingCode(part.oemCode);
|
||||
try {
|
||||
const result = await api.post<CastOemVoteResult>("/oem-votes", {
|
||||
oemCode: part.oemCode,
|
||||
vote,
|
||||
partId: part.id,
|
||||
vehicleId,
|
||||
categoryId,
|
||||
});
|
||||
setVoteSummaries((prev) => ({
|
||||
...prev,
|
||||
[result.oemCode]: { ...result.counts, myVote: result.myVote },
|
||||
}));
|
||||
if (result.isNew) {
|
||||
const isFirstVote = result.counts.compatible + result.counts.incompatible === 1;
|
||||
toast.success(`+${result.pointsAwarded} puan kazandınız!`, {
|
||||
description: isFirstVote
|
||||
? "Bu OEM kodunu ilk değerlendiren sizsiniz."
|
||||
: result.correct
|
||||
? "Çoğunluk görüşüyle aynı yöndesiniz."
|
||||
: "Oyunuz kaydedildi — çoğunluk şimdilik farklı görüşte.",
|
||||
});
|
||||
} else {
|
||||
toast.info("Oyunuz güncellendi.");
|
||||
}
|
||||
capture("oem_vote_cast", {
|
||||
oem_code: part.oemCode,
|
||||
vote,
|
||||
points_awarded: result.pointsAwarded,
|
||||
correct: result.correct,
|
||||
is_new: result.isNew,
|
||||
part_id: part.id,
|
||||
vehicle_id: vehicleId,
|
||||
category_id: categoryId,
|
||||
});
|
||||
} catch {
|
||||
toast.error("Oy kaydedilemedi", { description: "Lütfen tekrar deneyin." });
|
||||
} finally {
|
||||
setVotePendingCode(null);
|
||||
}
|
||||
},
|
||||
[vehicleId, categoryId],
|
||||
);
|
||||
|
||||
// PL24 "bk. tablo:NNN-NNN" cross-reference jump. Resolved at load → open the
|
||||
// target illustration directly. Unresolved (target branch not seeded yet) →
|
||||
// ask the server to drill it on demand; if that finds it, jump there, else
|
||||
@@ -265,6 +345,7 @@ export function PartsPanel({
|
||||
<th className="px-3 py-2 w-10">#</th>
|
||||
<th className="px-3 py-2">Parça Adı</th>
|
||||
<th className="px-3 py-2">OEM Kodu</th>
|
||||
<th className="px-3 py-2 w-24 text-center">Uyum</th>
|
||||
<th className="px-3 py-2 w-14 text-center">Adet</th>
|
||||
<th className="px-3 py-2">Pozisyon</th>
|
||||
{hasPrices && <th className="px-3 py-2 text-right">Fiyat</th>}
|
||||
@@ -290,7 +371,7 @@ export function PartsPanel({
|
||||
return (
|
||||
<tr key={part.id} className="border-b border-border/50 bg-muted/20">
|
||||
<td className="px-3 py-2 text-muted-foreground">{part.hotspotIndex}</td>
|
||||
<td className="px-3 py-2" colSpan={hasPrices ? 5 : 4}>
|
||||
<td className="px-3 py-2" colSpan={hasPrices ? 6 : 5}>
|
||||
{label && <span className="font-medium">{label}</span>}
|
||||
<span className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
{refs.map((ref) => {
|
||||
@@ -428,6 +509,15 @@ export function PartsPanel({
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{part.oemCode && part.oemCode !== "N/A" && (
|
||||
<OemVoteButtons
|
||||
summary={voteSummaries[part.oemCode]}
|
||||
pending={votePendingCode === part.oemCode}
|
||||
onVote={(vote) => castVote(part, vote)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">{part.quantity}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{part.position}</td>
|
||||
{hasPrices && (
|
||||
|
||||
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