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,37 @@
-- OEM community voting + gamification ledger.
-- oem_votes: one row per (user, oem_code) uyumlu/uyumsuz verdict cast from the
-- catalog part rows. Votes pool globally per code; part/vehicle/category are
-- analytics breadcrumbs only (catalog routes pass catalog_vehicles ids → no FK,
-- mirrors oem_code_copies). Re-votes update the row in place.
-- oem_vote_points: append-only, exactly one row per vote (vote_id unique),
-- written in the same transaction. points = 1 (vote) + 2 (agreed with strict
-- majority at cast time; first voter always 3). Never retro-adjusted.
CREATE TABLE "oem_votes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"oem_code" varchar(100) NOT NULL,
"vote" varchar(12) NOT NULL,
"part_id" uuid,
"vehicle_id" uuid,
"category_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "oem_vote_points" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"vote_id" uuid NOT NULL,
"oem_code" varchar(100) NOT NULL,
"points" integer NOT NULL,
"correct" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "oem_votes" ADD CONSTRAINT "oem_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "oem_vote_points" ADD CONSTRAINT "oem_vote_points_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "oem_vote_points" ADD CONSTRAINT "oem_vote_points_vote_id_oem_votes_id_fk" FOREIGN KEY ("vote_id") REFERENCES "public"."oem_votes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "oem_votes_user_oem_idx" ON "oem_votes" USING btree ("user_id","oem_code");--> statement-breakpoint
CREATE INDEX "oem_votes_oem_code_idx" ON "oem_votes" USING btree ("oem_code");--> statement-breakpoint
CREATE UNIQUE INDEX "oem_vote_points_vote_id_idx" ON "oem_vote_points" USING btree ("vote_id");--> statement-breakpoint
CREATE INDEX "oem_vote_points_user_id_idx" ON "oem_vote_points" USING btree ("user_id");

View File

@@ -106,6 +106,13 @@
"when": 1781136000000,
"tag": "0014_proxy_logs",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1781222400000,
"tag": "0015_oem_votes",
"breakpoints": true
}
]
}

View File

@@ -34,6 +34,7 @@ import { InternalAdminModule } from "./internal-admin/internal-admin.module";
import { JobsModule } from "./jobs/jobs.module";
import { MetaCapiModule } from "./meta-capi/meta-capi.module";
import { NotificationsModule } from "./notifications/notifications.module";
import { OemVotesModule } from "./oem-votes/oem-votes.module";
import { PartsModule } from "./parts/parts.module";
import { PaymentsModule } from "./payments/payments.module";
import { PlansModule } from "./plans/plans.module";
@@ -93,6 +94,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
TranslationsModule,
AdminModule,
AnalyticsModule,
OemVotesModule,
CatalogModule,
ChangelogModule,
ChatwootModule,

View File

@@ -655,3 +655,59 @@ export const proxyLogs = pgTable(
index("proxy_logs_banned_created_at_idx").on(table.banned, table.createdAt),
],
);
// ─── OEM Votes (community compatibility verdicts) ─────
// One row per (user, oemCode): a parts seller's uyumlu/uyumsuz verdict on an
// OEM code, cast from the catalog part rows. Votes pool globally per code —
// the part/vehicle/category columns are analytics breadcrumbs only (mirrors
// oem_code_copies: catalog routes pass catalog_vehicles ids here, so no FK).
// Re-voting updates `vote` in place; points are only ever granted on the
// first insert (see oem_vote_points).
export const oemVotes = pgTable(
"oem_votes",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
oemCode: varchar("oem_code", { length: 100 }).notNull(),
// compatible | incompatible
vote: varchar("vote", { length: 12 }).notNull(),
partId: uuid("part_id"),
vehicleId: uuid("vehicle_id"),
categoryId: uuid("category_id"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("oem_votes_user_oem_idx").on(table.userId, table.oemCode),
index("oem_votes_oem_code_idx").on(table.oemCode),
],
);
// ─── OEM Vote Points (gamification ledger) ────────────
// Append-only: exactly one row per oem_votes row (vote_id unique), written in
// the same transaction as the vote insert. `points` = 1 for voting +2 when
// the vote agreed with the strict majority at cast time (first voter on a
// code always agrees with themselves → 3). Awards are never retro-adjusted
// when the majority later flips. Leaderboard = SUM(points) per user.
export const oemVotePoints = pgTable(
"oem_vote_points",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
voteId: uuid("vote_id")
.notNull()
.references(() => oemVotes.id, { onDelete: "cascade" }),
oemCode: varchar("oem_code", { length: 100 }).notNull(),
points: integer("points").notNull(),
correct: boolean("correct").default(false).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("oem_vote_points_vote_id_idx").on(table.voteId),
index("oem_vote_points_user_id_idx").on(table.userId),
],
);

View File

@@ -0,0 +1,36 @@
import { BadRequestException, Body, Controller, Get, Post } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { castOemVoteSchema, lookupOemVotesSchema } from "./oem-votes.dto";
import { OemVotesService } from "./oem-votes.service";
@Controller("oem-votes")
export class OemVotesController {
constructor(private readonly oemVotesService: OemVotesService) {}
// Puan çiftliğine karşı insan-hızı sınırı: dakikada en fazla 30 oy.
@Post()
@Throttle({ default: { limit: 30, ttl: 60_000 } })
async cast(@CurrentUser("id") userId: string, @Body() body: unknown) {
const parsed = castOemVoteSchema.safeParse(body);
if (!parsed.success) {
throw new BadRequestException(parsed.error.issues[0]?.message ?? "Geçersiz oy verisi");
}
return this.oemVotesService.castVote(userId, parsed.data);
}
// Kod listesi URL sınırına sığmayacak kadar uzayabildiği için POST (bkz. /p/matched).
@Post("lookup")
async lookup(@CurrentUser("id") userId: string, @Body() body: unknown) {
const parsed = lookupOemVotesSchema.safeParse(body);
if (!parsed.success) {
throw new BadRequestException(parsed.error.issues[0]?.message ?? "Geçersiz kod listesi");
}
return { votes: await this.oemVotesService.lookup(userId, parsed.data.codes) };
}
@Get("leaderboard")
async leaderboard(@CurrentUser("id") userId: string) {
return this.oemVotesService.leaderboard(userId);
}
}

View File

@@ -0,0 +1,17 @@
import { z } from "zod";
export const castOemVoteSchema = z.object({
oemCode: z.string().trim().min(2, "OEM kodu geçersiz").max(100, "OEM kodu çok uzun"),
vote: z.enum(["compatible", "incompatible"]),
// Analitik bağlamı — katalog rotaları catalog_vehicles id'si yollayabilir,
// UUID olmayan değerler serviste sessizce düşürülür (oy yine kaydedilir).
partId: z.string().max(100).optional(),
vehicleId: z.string().max(100).optional(),
categoryId: z.string().max(100).optional(),
});
export type CastOemVoteInput = z.infer<typeof castOemVoteSchema>;
export const lookupOemVotesSchema = z.object({
codes: z.array(z.string().trim().min(1).max(100)).min(1).max(400),
});
export type LookupOemVotesInput = z.infer<typeof lookupOemVotesSchema>;

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { computeVoteAward, maskExpertName } from "./oem-votes.logic";
describe("computeVoteAward", () => {
it("ilk oy: kimse oylamamışsa 3 puan (1 oy + 2 doğru)", () => {
expect(computeVoteAward({ compatible: 0, incompatible: 0 }, "compatible")).toEqual({
points: 3,
correct: true,
});
expect(computeVoteAward({ compatible: 0, incompatible: 0 }, "incompatible")).toEqual({
points: 3,
correct: true,
});
});
it("1 kişi uyumlu demişken uyumsuz diyen 1 puan, uyumlu diyen 3 puan alır", () => {
const prior = { compatible: 1, incompatible: 0 };
expect(computeVoteAward(prior, "incompatible")).toEqual({ points: 1, correct: false });
expect(computeVoteAward(prior, "compatible")).toEqual({ points: 3, correct: true });
});
it("beraberliği bozan oy çoğunluğu kendi tarafına çevirdiği için 3 puan alır", () => {
expect(computeVoteAward({ compatible: 1, incompatible: 1 }, "compatible")).toEqual({
points: 3,
correct: true,
});
});
it("açık çoğunluğa karşı oy yalnız taban puanı alır", () => {
expect(computeVoteAward({ compatible: 5, incompatible: 1 }, "incompatible")).toEqual({
points: 1,
correct: false,
});
expect(computeVoteAward({ compatible: 5, incompatible: 1 }, "compatible")).toEqual({
points: 3,
correct: true,
});
});
it("oyumla beraberlik oluşuyorsa çoğunluk sağlanmaz, 1 puan", () => {
// 2-1 iken uyumsuz oyu → 2-2: kesin çoğunluk yok.
expect(computeVoteAward({ compatible: 2, incompatible: 1 }, "incompatible")).toEqual({
points: 1,
correct: false,
});
});
});
describe("maskExpertName", () => {
it("ad ve soyadın yalnız ilk harfini açık bırakır", () => {
expect(maskExpertName("Semih Yılmaz")).toBe("S*** Y***");
});
it("ara adları düşürür, soyad olarak son kelimeyi alır", () => {
expect(maskExpertName("Ali Rıza Demir")).toBe("A*** D***");
});
it("tek kelimelik adı maskeler", () => {
expect(maskExpertName("Semih")).toBe("S***");
});
it("Türkçe karakterleri doğru büyütür", () => {
expect(maskExpertName("ismail çelik")).toBe("İ*** Ç***");
});
it("boş ada güvenli düşer", () => {
expect(maskExpertName(" ")).toBe("Üye");
});
});

View File

@@ -0,0 +1,37 @@
export type OemVoteChoice = "compatible" | "incompatible";
export interface VoteCounts {
compatible: number;
incompatible: number;
}
export interface VoteAward {
points: number;
correct: boolean;
}
export const VOTE_BASE_POINTS = 1;
export const VOTE_MAJORITY_BONUS = 2;
// "Doğru oy" = kendi oyu da sayıldığında kesin çoğunlukla aynı tarafta olmak.
// İlk oy (0-0 → 1-0) ve beraberliği bozan oy çoğunluğu kendi tarafına
// çevirdiği için doğrudur (3 puan); mevcut çoğunluğa karşı oy yalnız taban
// puanı alır (1). Ödül oy anında kesinleşir, çoğunluk sonradan dönse bile
// geriye dönük düzeltilmez.
export function computeVoteAward(prior: VoteCounts, vote: OemVoteChoice): VoteAward {
const mine = (vote === "compatible" ? prior.compatible : prior.incompatible) + 1;
const other = vote === "compatible" ? prior.incompatible : prior.compatible;
const correct = mine > other;
return { points: VOTE_BASE_POINTS + (correct ? VOTE_MAJORITY_BONUS : 0), correct };
}
// Liderlik listesi adları KVKK-dostu: yalnız ad ve soyadın ilk harfi açık
// ("Semih Yılmaz" → "S*** Y***"). Ara adlar tamamen düşer; tek kelimelik
// adlarda o kelimenin ilk harfi kalır.
export function maskExpertName(fullName: string): string {
const words = fullName.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return "Üye";
const maskWord = (w: string) => `${w.charAt(0).toLocaleUpperCase("tr-TR")}***`;
if (words.length === 1) return maskWord(words[0]);
return `${maskWord(words[0])} ${maskWord(words[words.length - 1])}`;
}

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { OemVotesController } from "./oem-votes.controller";
import { OemVotesService } from "./oem-votes.service";
@Module({
controllers: [OemVotesController],
providers: [OemVotesService],
exports: [OemVotesService],
})
export class OemVotesModule {}

View File

@@ -0,0 +1,200 @@
import { Inject, Injectable } from "@nestjs/common";
import { and, count, desc, eq, inArray, sql } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { oemVotePoints, oemVotes, users } from "../database/schema/core";
import {
type OemVoteChoice,
type VoteCounts,
computeVoteAward,
maskExpertName,
} from "./oem-votes.logic";
type Tx = Parameters<Parameters<Database["transaction"]>[0]>[0];
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const uuidOrNull = (value?: string) => (value && UUID_RE.test(value) ? value : null);
export interface CastVoteResult {
oemCode: string;
myVote: OemVoteChoice;
counts: VoteCounts;
pointsAwarded: number;
correct: boolean | null;
isNew: boolean;
}
export interface OemVoteSummary extends VoteCounts {
myVote: OemVoteChoice | null;
}
export interface LeaderboardEntry {
rank: number;
name: string;
points: number;
votes: number;
isMe: boolean;
}
@Injectable()
export class OemVotesService {
constructor(@Inject(DATABASE) private db: Database) {}
async castVote(
userId: string,
input: {
oemCode: string;
vote: OemVoteChoice;
partId?: string;
vehicleId?: string;
categoryId?: string;
},
): Promise<CastVoteResult> {
const oemCode = input.oemCode.trim();
const context = {
partId: uuidOrNull(input.partId),
vehicleId: uuidOrNull(input.vehicleId),
categoryId: uuidOrNull(input.categoryId),
};
return this.db.transaction(async (tx) => {
// Aynı kod üzerindeki eşzamanlı oyları sıraya sok: ilk-oy bonusu ve
// çoğunluk hesabı yarışsız, deterministik kalır. Kod bazlı kilit —
// farklı kodlar birbirini bekletmez, tx sonunda otomatik bırakılır.
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${`oem-vote:${oemCode}`}))`);
const [existing] = await tx
.select({ id: oemVotes.id, vote: oemVotes.vote })
.from(oemVotes)
.where(and(eq(oemVotes.userId, userId), eq(oemVotes.oemCode, oemCode)))
.limit(1);
if (existing) {
// Fikir değişikliği oyu günceller ama puan üretmez (puan çiftliği yok).
if (existing.vote !== input.vote) {
await tx
.update(oemVotes)
.set({ vote: input.vote, ...context, updatedAt: new Date() })
.where(eq(oemVotes.id, existing.id));
}
return {
oemCode,
myVote: input.vote,
counts: await this.countVotes(tx, oemCode),
pointsAwarded: 0,
correct: null,
isNew: false,
};
}
const prior = await this.countVotes(tx, oemCode);
const award = computeVoteAward(prior, input.vote);
const [inserted] = await tx
.insert(oemVotes)
.values({ userId, oemCode, vote: input.vote, ...context })
.returning({ id: oemVotes.id });
await tx.insert(oemVotePoints).values({
userId,
voteId: inserted.id,
oemCode,
points: award.points,
correct: award.correct,
});
return {
oemCode,
myVote: input.vote,
counts: {
compatible: prior.compatible + (input.vote === "compatible" ? 1 : 0),
incompatible: prior.incompatible + (input.vote === "incompatible" ? 1 : 0),
},
pointsAwarded: award.points,
correct: award.correct,
isNew: true,
};
});
}
// Parça listesi açılırken görünen kodların oy özetleri — tek toplu sorgu.
async lookup(userId: string, codes: string[]): Promise<Record<string, OemVoteSummary>> {
const unique = [...new Set(codes.map((c) => c.trim()).filter(Boolean))];
if (unique.length === 0) return {};
const [tallies, mine] = await Promise.all([
this.db
.select({ oemCode: oemVotes.oemCode, vote: oemVotes.vote, total: count() })
.from(oemVotes)
.where(inArray(oemVotes.oemCode, unique))
.groupBy(oemVotes.oemCode, oemVotes.vote),
this.db
.select({ oemCode: oemVotes.oemCode, vote: oemVotes.vote })
.from(oemVotes)
.where(and(eq(oemVotes.userId, userId), inArray(oemVotes.oemCode, unique))),
]);
const result: Record<string, OemVoteSummary> = {};
const entry = (code: string) => {
result[code] ??= { compatible: 0, incompatible: 0, myVote: null };
return result[code];
};
for (const row of tallies) {
const summary = entry(row.oemCode);
if (row.vote === "compatible") summary.compatible = row.total;
else summary.incompatible = row.total;
}
for (const row of mine) {
entry(row.oemCode).myVote = row.vote as OemVoteChoice;
}
return result;
}
// Parça Uzmanları: puana göre herkes, adlar maskeli ("S*** Y***"). Yanıt
// userId sızdırmaz; istek sahibi kendi satırını isMe ile bulur.
async leaderboard(userId: string): Promise<{
entries: LeaderboardEntry[];
me: { rank: number | null; points: number; votes: number };
}> {
const totalPoints = sql<number>`sum(${oemVotePoints.points})::int`;
const rows = await this.db
.select({
userId: oemVotePoints.userId,
name: users.name,
points: totalPoints,
votes: count(),
})
.from(oemVotePoints)
.innerJoin(users, eq(users.id, oemVotePoints.userId))
.groupBy(oemVotePoints.userId, users.name)
.orderBy(desc(totalPoints), sql`min(${oemVotePoints.createdAt}) asc`)
.limit(500);
const entries = rows.map((row, i) => ({
rank: i + 1,
name: maskExpertName(row.name),
points: row.points,
votes: row.votes,
isMe: row.userId === userId,
}));
const my = entries.find((e) => e.isMe);
return {
entries,
me: { rank: my?.rank ?? null, points: my?.points ?? 0, votes: my?.votes ?? 0 },
};
}
private async countVotes(tx: Tx, oemCode: string): Promise<VoteCounts> {
const rows = await tx
.select({ vote: oemVotes.vote, total: count() })
.from(oemVotes)
.where(eq(oemVotes.oemCode, oemCode))
.groupBy(oemVotes.vote);
const counts: VoteCounts = { compatible: 0, incompatible: 0 };
for (const row of rows) {
if (row.vote === "compatible") counts.compatible = row.total;
else counts.incompatible = row.total;
}
return counts;
}
}

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

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

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

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

View File

@@ -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");
});
});

View File

@@ -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 && (

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