Revert "Merge pull request 'dev' (#130) from dev into main"
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

This reverts commit eccecf73e6, reversing
changes made to 4bcac424e0.
This commit is contained in:
2026-06-12 00:48:58 +03:00
parent eccecf73e6
commit 5c7803e12a
37 changed files with 232 additions and 1860 deletions

View File

@@ -1,102 +0,0 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const captureMock = vi.fn();
const toastSuccessMock = vi.fn();
vi.mock("@/lib/posthog", () => ({
capture: (...args: unknown[]) => captureMock(...args),
}));
vi.mock("@/lib/api-client", () => ({
api: {
post: vi.fn().mockResolvedValue({}),
get: vi.fn().mockResolvedValue({}),
},
}));
vi.mock("@/lib/toast", () => ({
toast: {
success: (...args: unknown[]) => toastSuccessMock(...args),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
},
}));
vi.mock("@tanstack/react-router", () => ({
Link: ({
children,
to,
params,
}: { children: React.ReactNode; to: string; params?: { code: string } }) => (
<a href={params ? to.replace("$code", params.code) : to}>{children}</a>
),
}));
import { api } from "@/lib/api-client";
import { OemSuggestionsSection } from "../oem-suggestions-section";
beforeEach(() => {
captureMock.mockClear();
toastSuccessMock.mockClear();
vi.mocked(api.get).mockReset().mockResolvedValue({ suggestions: [] });
vi.mocked(api.post).mockReset().mockResolvedValue({});
});
afterEach(() => {
cleanup();
});
describe("OemSuggestionsSection", () => {
it("lists aggregated suggestions with supporter counts", async () => {
vi.mocked(api.get).mockResolvedValue({
suggestions: [
{ brand: "Bosch", code: "0 986 478 123", count: 3, mine: true },
{ brand: "TRW", code: "DF4823", count: 1, mine: false },
],
});
render(<OemSuggestionsSection oemCode="8V0615423E" />);
await waitFor(() => {
expect(api.get).toHaveBeenCalledWith("/oem-suggestions?code=8V0615423E");
expect(screen.getByText("Bosch")).toBeInTheDocument();
});
expect(screen.getByRole("link", { name: "0 986 478 123" })).toBeInTheDocument();
expect(screen.getByText("sizin öneriniz")).toBeInTheDocument();
});
it("submits a brand + code suggestion and refreshes the list", async () => {
vi.mocked(api.post).mockResolvedValue({
created: true,
suggestions: [{ brand: "Valeo", code: "PHC123", count: 1, mine: true }],
});
render(<OemSuggestionsSection oemCode="X1" />);
await waitFor(() => expect(api.get).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: /Muadil öner/ }));
fireEvent.change(screen.getByPlaceholderText("Marka (ör. Bosch)"), {
target: { value: "Valeo" },
});
fireEvent.change(screen.getByPlaceholderText("Parça kodu"), {
target: { value: "PHC123" },
});
fireEvent.click(screen.getByRole("button", { name: "Gönder" }));
await waitFor(() => {
expect(api.post).toHaveBeenCalledWith("/oem-suggestions", {
oemCode: "X1",
brand: "Valeo",
suggestedCode: "PHC123",
});
expect(toastSuccessMock).toHaveBeenCalled();
expect(captureMock).toHaveBeenCalledWith(
"oem_suggestion_submitted",
expect.objectContaining({ oem_code: "X1", brand: "Valeo", created: true }),
);
expect(screen.getByText("Valeo")).toBeInTheDocument();
});
});
});

View File

@@ -1,100 +0,0 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const captureMock = vi.fn();
const toastSuccessMock = vi.fn();
vi.mock("@/lib/posthog", () => ({
capture: (...args: unknown[]) => captureMock(...args),
}));
vi.mock("@/lib/api-client", () => ({
api: {
post: vi.fn().mockResolvedValue({}),
get: vi.fn().mockResolvedValue({}),
},
}));
vi.mock("@/lib/toast", () => ({
toast: {
success: (...args: unknown[]) => toastSuccessMock(...args),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
},
}));
vi.mock("@tanstack/react-router", () => ({
Link: ({ children, to }: { children: React.ReactNode; to: string }) => (
<a href={to}>{children}</a>
),
}));
import { api } from "@/lib/api-client";
import { OemVoteCard } from "../oem-vote-card";
beforeEach(() => {
captureMock.mockClear();
toastSuccessMock.mockClear();
vi.mocked(api.post).mockReset().mockResolvedValue({});
});
afterEach(() => {
cleanup();
});
describe("OemVoteCard", () => {
it("loads the summary for the code and shows the counts", async () => {
vi.mocked(api.post).mockResolvedValue({
votes: { "8V0615423E": { compatible: 4, incompatible: 1, myVote: "compatible" } },
});
render(<OemVoteCard oemCode="8V0615423E" />);
const upButton = screen.getByRole("button", { name: /Uyumlu/ });
const downButton = screen.getByRole("button", { name: /Uyumsuz/ });
await waitFor(() => {
expect(api.post).toHaveBeenCalledWith("/oem-votes/lookup", { codes: ["8V0615423E"] });
expect(upButton.textContent).toContain("4");
expect(downButton.textContent).toContain("1");
});
expect(upButton).toHaveAttribute("aria-pressed", "true");
});
it("casts a vote, updates the counts and reports the points", async () => {
const postMock = vi.mocked(api.post);
postMock.mockImplementation((path: string) => {
if (path === "/oem-votes") {
return Promise.resolve({
oemCode: "X1",
myVote: "incompatible",
counts: { compatible: 0, incompatible: 1 },
pointsAwarded: 3,
correct: true,
isNew: true,
});
}
return Promise.resolve({ votes: {} });
});
render(<OemVoteCard oemCode="X1" />);
const downButton = screen.getByRole("button", { name: /Uyumsuz/ });
await waitFor(() => expect(downButton).toBeEnabled());
fireEvent.click(downButton);
await waitFor(() => {
expect(postMock).toHaveBeenCalledWith("/oem-votes", {
oemCode: "X1",
vote: "incompatible",
});
expect(toastSuccessMock).toHaveBeenCalledWith("+3 puan kazandınız!", expect.anything());
expect(captureMock).toHaveBeenCalledWith(
"oem_vote_cast",
expect.objectContaining({ oem_code: "X1", vote: "incompatible", surface: "oem_detail" }),
);
});
expect(downButton.textContent).toContain("1");
expect(downButton).toHaveAttribute("aria-pressed", "true");
});
});

View File

@@ -1,158 +0,0 @@
import { api } from "@/lib/api-client";
import { capture } from "@/lib/posthog";
import { toast } from "@/lib/toast";
import { Badge, Button, Input, Skeleton } from "@sase/ui";
import { Link } from "@tanstack/react-router";
import { Plus, Users } from "lucide-react";
import { useEffect, useState } from "react";
export interface AggregatedSuggestion {
brand: string;
code: string;
count: number;
mine: boolean;
}
// Topluluk muadil önerileri: P kataloğunda eşleşmesi olmayan (ya da eksik
// kalan) OEM kodları için parça satıcılarından marka + kod toplar. Aynı
// öneriyi veren kullanıcı sayısı rozetle gösterilir; önerilen kod kendi OEM
// detay sayfasına linklenir.
export function OemSuggestionsSection({ oemCode }: { oemCode: string }) {
const [suggestions, setSuggestions] = useState<AggregatedSuggestion[] | null>(null);
const [formOpen, setFormOpen] = useState(false);
const [brand, setBrand] = useState("");
const [code, setCode] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
let cancelled = false;
setSuggestions(null);
api
.get<{ suggestions: AggregatedSuggestion[] }>(
`/oem-suggestions?code=${encodeURIComponent(oemCode)}`,
)
.then((res) => {
if (!cancelled) setSuggestions(res?.suggestions ?? []);
})
.catch(() => {
if (!cancelled) setSuggestions([]);
});
return () => {
cancelled = true;
};
}, [oemCode]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
try {
const res = await api.post<{ created: boolean; suggestions: AggregatedSuggestion[] }>(
"/oem-suggestions",
{ oemCode, brand, suggestedCode: code },
);
setSuggestions(res.suggestions);
if (res.created) {
toast.success("Öneriniz eklendi, teşekkürler!", {
description: "Aynı öneriyi veren satıcı arttıkça öneri güçlenir.",
});
} else {
toast.info("Bu öneriyi zaten eklemişsiniz.");
}
capture("oem_suggestion_submitted", {
oem_code: oemCode,
brand,
created: res.created,
});
setBrand("");
setCode("");
setFormOpen(false);
} catch {
toast.error("Öneri kaydedilemedi", { description: "Lütfen tekrar deneyin." });
} finally {
setSubmitting(false);
}
};
return (
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 className="text-sm font-semibold">Topluluk muadil önerileri</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
Bu kodun muadilini biliyorsanız marka + parça kodu olarak ekleyin.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => setFormOpen((o) => !o)}>
<Plus className="mr-1.5 size-4" />
Muadil öner
</Button>
</div>
{formOpen && (
<form
onSubmit={submit}
className="flex flex-col gap-2 rounded-xl border border-border bg-background p-3 sm:flex-row"
>
<Input
value={brand}
onChange={(e) => setBrand(e.target.value)}
placeholder="Marka (ör. Bosch)"
required
minLength={2}
maxLength={80}
className="sm:max-w-48"
/>
<Input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Parça kodu"
required
minLength={2}
maxLength={100}
className="font-mono"
/>
<Button type="submit" disabled={submitting}>
{submitting ? "Gönderiliyor…" : "Gönder"}
</Button>
</form>
)}
{suggestions === null ? (
<Skeleton className="h-14 w-full rounded-xl" />
) : suggestions.length === 0 ? (
<p className="rounded-xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
Henüz öneri yok muadilini biliyorsanız ilk öneriyi siz ekleyin.
</p>
) : (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border">
{suggestions.map((s) => (
<li
key={`${s.brand.toLocaleLowerCase("tr-TR")}|${s.code}`}
className="flex items-center justify-between gap-3 bg-background px-4 py-2.5"
>
<span className="min-w-0">
<span className="text-sm font-medium">{s.brand}</span>{" "}
<Link
to="/dashboard/oem/$code"
params={{ code: s.code }}
className="font-mono text-xs underline decoration-dotted underline-offset-2 hover:decoration-solid"
>
{s.code}
</Link>
{s.mine && (
<span className="ml-2 text-[10px] font-semibold uppercase tracking-wide text-primary">
sizin öneriniz
</span>
)}
</span>
<Badge variant="secondary" title="Bu muadili öneren kullanıcı sayısı">
<Users className="mr-1 size-3" />
{s.count}
</Badge>
</li>
))}
</ul>
)}
</section>
);
}

View File

@@ -1,142 +0,0 @@
import { api } from "@/lib/api-client";
import { capture } from "@/lib/posthog";
import { toast } from "@/lib/toast";
import { cn } from "@sase/ui";
import { Link } from "@tanstack/react-router";
import { ThumbsDown, ThumbsUp } from "lucide-react";
import { useEffect, useState } from "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;
}
const EMPTY_SUMMARY: OemVoteSummary = { compatible: 0, incompatible: 0, myVote: null };
// OEM detay sayfasındaki topluluk oyu kartı: uyumlu/uyumsuz + sayaçlar.
// Kendi durumunu yönetir; oy puanları toast ile bildirilir (oy +1, çoğunluk
// +2, ilk oy 3 — kural API'deki computeVoteAward'da).
export function OemVoteCard({ oemCode }: { oemCode: string }) {
const [summary, setSummary] = useState<OemVoteSummary | null>(null);
const [pending, setPending] = useState(false);
useEffect(() => {
let cancelled = false;
setSummary(null);
api
.post<{ votes: Record<string, OemVoteSummary> }>("/oem-votes/lookup", { codes: [oemCode] })
.then((res) => {
if (!cancelled) setSummary(res?.votes?.[oemCode] ?? EMPTY_SUMMARY);
})
.catch(() => {
if (!cancelled) setSummary(EMPTY_SUMMARY);
});
return () => {
cancelled = true;
};
}, [oemCode]);
const cast = async (vote: OemVoteChoice) => {
setPending(true);
try {
const result = await api.post<CastOemVoteResult>("/oem-votes", { oemCode, vote });
setSummary({ ...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: oemCode,
vote,
points_awarded: result.pointsAwarded,
correct: result.correct,
is_new: result.isNew,
surface: "oem_detail",
});
} catch {
toast.error("Oy kaydedilemedi", { description: "Lütfen tekrar deneyin." });
} finally {
setPending(false);
}
};
const options = [
{
vote: "compatible" as const,
icon: ThumbsUp,
label: "Uyumlu",
count: summary?.compatible ?? 0,
activeClass: "border-green-500/50 bg-green-500/10 text-green-500",
},
{
vote: "incompatible" as const,
icon: ThumbsDown,
label: "Uyumsuz",
count: summary?.incompatible ?? 0,
activeClass: "border-red-500/50 bg-red-500/10 text-red-500",
},
];
return (
<section className="rounded-xl border border-border bg-background p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h2 className="text-sm font-semibold">Topluluk uyumluluk oyu</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
Bu OEM kodu sizce doğru mu? Oyunuz diğer parça satıcılarına yol gösterir.
</p>
</div>
<div className="flex gap-2">
{options.map(({ vote, icon: Icon, label, count, activeClass }) => {
const isActive = summary?.myVote === vote;
return (
<button
key={vote}
type="button"
disabled={pending || summary === null}
aria-pressed={isActive}
onClick={() => cast(vote)}
className={cn(
"inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors disabled:opacity-50",
isActive
? activeClass
: "border-border text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
<Icon className="size-4 shrink-0" fill={isActive ? "currentColor" : "none"} />
{label}
<span className="font-semibold tabular-nums">{count}</span>
</button>
);
})}
</div>
</div>
<p className="mt-3 text-xs text-muted-foreground">
Her oy puan kazandırır ·{" "}
<Link to="/dashboard/uzmanlar" className="font-medium text-primary hover:underline">
Parça Uzmanları sıralaması
</Link>
</p>
</section>
);
}

View File

@@ -1,96 +0,0 @@
// 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

@@ -1,166 +0,0 @@
// 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

@@ -1,44 +0,0 @@
// 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

@@ -27,7 +27,6 @@ vi.mock("@/stores/schema.store", () => ({
}),
}));
import { api } from "@/lib/api-client";
import { PartsPanel } from "../parts-panel";
const buildPart = (overrides: Partial<Part> = {}): Part => ({
@@ -50,7 +49,6 @@ beforeEach(() => {
captureMock.mockClear();
setSelectedGroupMock.mockClear();
setHighlightedGroupMock.mockClear();
vi.mocked(api.post).mockReset().mockResolvedValue({});
});
afterEach(() => {
@@ -150,25 +148,4 @@ describe("PartsPanel", () => {
parts_count: parts.length,
});
});
it("links every OEM code to the detail page without a /p/matched gate", () => {
const postMock = vi.mocked(api.post);
render(<PartsPanel parts={[buildPart()]} vehicleId="v1" categoryId="c1" />);
const link = screen.getByRole("link", { name: "OEM-1" });
expect(link).toHaveAttribute("href", "/dashboard/oem/OEM-1");
// Liste açılışı artık toplu istek atmaz (/p/matched ve oy lookup'ı kalktı);
// tek istisna kopyalama anındaki fire-and-forget analytics POST'udur.
expect(postMock).not.toHaveBeenCalled();
});
it("does not render Uyum or Adet columns", () => {
render(<PartsPanel parts={[buildPart({ quantity: 4 })]} />);
expect(screen.queryByText("Adet")).not.toBeInTheDocument();
expect(screen.queryByText("Uyum")).not.toBeInTheDocument();
const headers = screen.getAllByRole("columnheader").map((th) => th.textContent);
expect(headers).toEqual(["#", "Parça Adı", "OEM Kodu", "Pozisyon"]);
});
});

View File

@@ -37,6 +37,34 @@ export function PartsPanel({
const viewedKeyRef = useRef<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [resolvingCode, setResolvingCode] = useState<string | null>(null);
// OEM codes that resolve to a non-empty cross-reference page. Only these are
// rendered as links — unmatched codes stay plain text so a click never lands
// on an empty "no equivalents" page. One batch lookup per parts list.
const [matchedOemCodes, setMatchedOemCodes] = useState<Set<string>>(new Set());
const oemCodes = useMemo(
() => [...new Set(parts.map((p) => p.oemCode).filter((c) => c && c !== "N/A"))],
[parts],
);
useEffect(() => {
if (oemCodes.length === 0) {
setMatchedOemCodes(new Set());
return;
}
let cancelled = false;
api
.post<{ matched: string[] }>("/p/matched", { codes: oemCodes })
.then((res) => {
if (!cancelled) setMatchedOemCodes(new Set(res?.matched ?? []));
})
.catch(() => {
if (!cancelled) setMatchedOemCodes(new Set());
});
return () => {
cancelled = true;
};
}, [oemCodes]);
// PL24 "bk. tablo:NNN-NNN" cross-reference jump. Resolved at load → open the
// target illustration directly. Unresolved (target branch not seeded yet) →
@@ -237,6 +265,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-14 text-center">Adet</th>
<th className="px-3 py-2">Pozisyon</th>
{hasPrices && <th className="px-3 py-2 text-right">Fiyat</th>}
</tr>
@@ -261,7 +290,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 ? 4 : 3}>
<td className="px-3 py-2" colSpan={hasPrices ? 5 : 4}>
{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) => {
@@ -364,13 +393,14 @@ export function PartsPanel({
)}
</button>
)}
{part.oemCode && part.oemCode !== "N/A" ? (
// Every code links to the OEM detail page — without a P
// cross-reference the page still carries the community
// vote, equivalent suggestions and the reverse catalog.
// Plain left-click → in-app client navigation (no full
// SPA reload); real href kept so ctrl/cmd/middle-click
// still opens a new tab.
{part.oemCode &&
part.oemCode !== "N/A" &&
matchedOemCodes.has(part.oemCode) ? (
// Link ONLY codes with a real cross-reference. Plain
// left-click → in-app client navigation (no full SPA
// reload — the new-tab boot was the slow part). Real href
// kept so ctrl/cmd/middle-click still opens a new tab.
// Unmatched codes fall through to plain text.
<a
href={`/dashboard/oem/${encodeURIComponent(part.oemCode)}`}
title="Uyumlu parça kodlarını gör"
@@ -398,6 +428,7 @@ export function PartsPanel({
)}
</span>
</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 && (
<td className="px-3 py-2 text-right text-xs">

View File

@@ -0,0 +1,118 @@
import { useChangelog } from "@/hooks/use-changelog";
import { useTranslation } from "@/lib/i18n";
import type { ChangelogEntry } from "@sase/shared";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Badge,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Skeleton,
} from "@sase/ui";
import { CalendarDays } from "lucide-react";
function formatDate(iso: string, locale: "tr" | "en"): string {
const date = new Date(iso);
const options: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "long",
year: "numeric",
};
return date.toLocaleDateString(locale === "tr" ? "tr-TR" : "en-US", options);
}
function changeTypeLabel(
changeType: ChangelogEntry["changeType"],
t: (key: string) => string,
): string {
return t(`settings.changelog.changeType.${changeType}`);
}
function ChangelogSkeleton() {
return (
<div className="space-y-0">
{[1, 2, 3].map((i) => (
<div key={i} className="relative pb-6 pl-8">
<div className="absolute left-0 top-1.5 h-2.5 w-2.5 rounded-full bg-muted-foreground/20" />
<div className="space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-16 rounded-md" />
<Skeleton className="h-5 w-48" />
</div>
<Skeleton className="h-4 w-28" />
</div>
</div>
))}
</div>
);
}
function ChangelogEmpty({ t }: { t: (key: string) => string }) {
return (
<Card className="border-dashed">
<CardHeader className="text-center">
<CalendarDays className="mx-auto h-10 w-10 text-muted-foreground" />
<CardTitle>{t("settings.changelog.emptyTitle")}</CardTitle>
<CardDescription>{t("settings.changelog.emptyDescription")}</CardDescription>
</CardHeader>
</Card>
);
}
export function ChangelogTab() {
const { data: entries, isLoading } = useChangelog();
const { t, locale } = useTranslation();
if (isLoading) {
return <ChangelogSkeleton />;
}
if (!entries || entries.length === 0) {
return <ChangelogEmpty t={t} />;
}
return (
<Card>
<CardHeader>
<CardTitle>{t("settings.changelog.title")}</CardTitle>
<CardDescription>{t("settings.changelog.description")}</CardDescription>
</CardHeader>
<CardContent>
<Accordion
type="single"
collapsible
className="relative border-l-2 border-muted-foreground/20"
>
{entries.map((entry) => (
<AccordionItem key={entry.id} value={entry.id} className="border-b-0 pl-6">
<div className="absolute left-[-5px] mt-6 h-2.5 w-2.5 rounded-full border-2 border-background bg-foreground dark:bg-primary" />
<AccordionTrigger className="py-3 hover:no-underline">
<div className="flex flex-col items-start gap-1.5 text-left">
<div className="flex flex-wrap items-center gap-2">
<Badge changeType={entry.changeType}>
{changeTypeLabel(entry.changeType, t)}
</Badge>
<span className="font-medium">{entry.title}</span>
</div>
<span className="text-xs text-muted-foreground">
{formatDate(entry.publishedAt, locale)}
</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
{entry.description}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,11 @@
import { api } from "@/lib/api-client";
import type { ChangelogEntry } from "@sase/shared";
import { useQuery } from "@tanstack/react-query";
export function useChangelog() {
return useQuery({
queryKey: ["changelog"],
queryFn: () => api.get<ChangelogEntry[]>("/changelog"),
staleTime: 30 * 60 * 1000, // 30 min — matches Redis cache TTL
});
}

View File

@@ -74,7 +74,6 @@
"dashboard": "Dashboard",
"search": "Search",
"history": "History",
"experts": "Parts Experts",
"catalog": "Catalog",
"subscription": "Subscription",
"billing": "Payment",
@@ -83,6 +82,7 @@
"logout": "Log Out",
"contact": "Contact",
"blog": "Blog",
"changelog": "What's New",
"account": "Account",
"sectionMain": "Main Menu",
"sectionAccount": "Account",
@@ -522,6 +522,7 @@
"connections": "Connections",
"referral": "Referral",
"account": "Account",
"changelog": "Changelog",
"notifications": "Notifications"
},
"preferences": {
@@ -592,6 +593,17 @@
"typeConfirm": "Type 'DELETE' to confirm",
"confirmWord": "DELETE"
},
"changelog": {
"title": "Changelog",
"description": "Latest platform updates, new features, and bug fixes.",
"emptyTitle": "No updates yet",
"emptyDescription": "Platform updates will appear here soon.",
"changeType": {
"fix": "Fix",
"feature": "New Feature",
"improvement": "Improvement"
}
},
"notifications": {
"title": "Notification preferences",
"description": "Choose which lifecycle e-mails you want to receive. Account security and payment notifications keep coming."

View File

@@ -74,7 +74,6 @@
"dashboard": "Gösterge Paneli",
"search": "Arama",
"history": "Geçmiş",
"experts": "Parça Uzmanları",
"catalog": "Katalog",
"subscription": "Abonelik",
"billing": "Ödeme",
@@ -83,6 +82,7 @@
"logout": ıkış Yap",
"contact": "İletişim",
"blog": "Blog",
"changelog": "Yenilikler",
"account": "Hesap",
"sectionMain": "Ana Menü",
"sectionAccount": "Hesap",
@@ -522,6 +522,7 @@
"connections": "Bağlantılar",
"referral": "Referans",
"account": "Hesap",
"changelog": "Değişiklik Günlüğü",
"notifications": "Bildirimler"
},
"preferences": {
@@ -592,6 +593,17 @@
"typeConfirm": "Onaylamak için 'SİL' yazın",
"confirmWord": "SİL"
},
"changelog": {
"title": "Değişiklik Günlüğü",
"description": "Platformdaki son güncellemeler, yeni özellikler ve hata düzeltmeleri.",
"emptyTitle": "Henüz güncelleme yok",
"emptyDescription": "Yakında platform güncellemeleri burada görünecek.",
"changeType": {
"fix": "Düzeltme",
"feature": "Yeni Özellik",
"improvement": "Geliştirme"
}
},
"notifications": {
"title": "Bildirim tercihleri",
"description": "Hangi lifecycle maillerini almak istediğini seç. Hesap güvenliği ve ödeme bildirimleri her zaman gelmeye devam eder."

View File

@@ -21,12 +21,12 @@ import { Route as AboutRouteImport } from './routes/about'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DashboardIndexRouteImport } from './routes/dashboard/index'
import { Route as DashboardUzmanlarRouteImport } from './routes/dashboard/uzmanlar'
import { Route as DashboardSettingsRouteImport } from './routes/dashboard/settings'
import { Route as DashboardServiceTestRouteImport } from './routes/dashboard/service-test'
import { Route as DashboardSearchRouteImport } from './routes/dashboard/search'
import { Route as DashboardHistoryRouteImport } from './routes/dashboard/history'
import { Route as DashboardContactRouteImport } from './routes/dashboard/contact'
import { Route as DashboardChangelogRouteImport } from './routes/dashboard/changelog'
import { Route as DashboardBlogRouteImport } from './routes/dashboard/blog'
import { Route as DashboardBillingRouteImport } from './routes/dashboard/billing'
import { Route as BlogSlugRouteImport } from './routes/blog_/$slug'
@@ -117,11 +117,6 @@ const DashboardIndexRoute = DashboardIndexRouteImport.update({
path: '/',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardUzmanlarRoute = DashboardUzmanlarRouteImport.update({
id: '/uzmanlar',
path: '/uzmanlar',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardSettingsRoute = DashboardSettingsRouteImport.update({
id: '/settings',
path: '/settings',
@@ -147,6 +142,11 @@ const DashboardContactRoute = DashboardContactRouteImport.update({
path: '/contact',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardChangelogRoute = DashboardChangelogRouteImport.update({
id: '/changelog',
path: '/changelog',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardBlogRoute = DashboardBlogRouteImport.update({
id: '/blog',
path: '/blog',
@@ -331,12 +331,12 @@ export interface FileRoutesByFullPath {
'/blog/$slug': typeof BlogSlugRoute
'/dashboard/billing': typeof DashboardBillingRoute
'/dashboard/blog': typeof DashboardBlogRoute
'/dashboard/changelog': typeof DashboardChangelogRoute
'/dashboard/contact': typeof DashboardContactRoute
'/dashboard/history': typeof DashboardHistoryRoute
'/dashboard/search': typeof DashboardSearchRoute
'/dashboard/service-test': typeof DashboardServiceTestRoute
'/dashboard/settings': typeof DashboardSettingsRoute
'/dashboard/uzmanlar': typeof DashboardUzmanlarRoute
'/dashboard/': typeof DashboardIndexRoute
'/dashboard/admin/analytics': typeof DashboardAdminAnalyticsRoute
'/dashboard/admin/copy-logs': typeof DashboardAdminCopyLogsRoute
@@ -379,12 +379,12 @@ export interface FileRoutesByTo {
'/blog/$slug': typeof BlogSlugRoute
'/dashboard/billing': typeof DashboardBillingRoute
'/dashboard/blog': typeof DashboardBlogRoute
'/dashboard/changelog': typeof DashboardChangelogRoute
'/dashboard/contact': typeof DashboardContactRoute
'/dashboard/history': typeof DashboardHistoryRoute
'/dashboard/search': typeof DashboardSearchRoute
'/dashboard/service-test': typeof DashboardServiceTestRoute
'/dashboard/settings': typeof DashboardSettingsRoute
'/dashboard/uzmanlar': typeof DashboardUzmanlarRoute
'/dashboard': typeof DashboardIndexRoute
'/dashboard/admin/analytics': typeof DashboardAdminAnalyticsRoute
'/dashboard/admin/copy-logs': typeof DashboardAdminCopyLogsRoute
@@ -430,12 +430,12 @@ export interface FileRoutesById {
'/blog_/$slug': typeof BlogSlugRoute
'/dashboard/billing': typeof DashboardBillingRoute
'/dashboard/blog': typeof DashboardBlogRoute
'/dashboard/changelog': typeof DashboardChangelogRoute
'/dashboard/contact': typeof DashboardContactRoute
'/dashboard/history': typeof DashboardHistoryRoute
'/dashboard/search': typeof DashboardSearchRoute
'/dashboard/service-test': typeof DashboardServiceTestRoute
'/dashboard/settings': typeof DashboardSettingsRoute
'/dashboard/uzmanlar': typeof DashboardUzmanlarRoute
'/dashboard/': typeof DashboardIndexRoute
'/dashboard/admin/analytics': typeof DashboardAdminAnalyticsRoute
'/dashboard/admin/copy-logs': typeof DashboardAdminCopyLogsRoute
@@ -481,12 +481,12 @@ export interface FileRouteTypes {
| '/blog/$slug'
| '/dashboard/billing'
| '/dashboard/blog'
| '/dashboard/changelog'
| '/dashboard/contact'
| '/dashboard/history'
| '/dashboard/search'
| '/dashboard/service-test'
| '/dashboard/settings'
| '/dashboard/uzmanlar'
| '/dashboard/'
| '/dashboard/admin/analytics'
| '/dashboard/admin/copy-logs'
@@ -529,12 +529,12 @@ export interface FileRouteTypes {
| '/blog/$slug'
| '/dashboard/billing'
| '/dashboard/blog'
| '/dashboard/changelog'
| '/dashboard/contact'
| '/dashboard/history'
| '/dashboard/search'
| '/dashboard/service-test'
| '/dashboard/settings'
| '/dashboard/uzmanlar'
| '/dashboard'
| '/dashboard/admin/analytics'
| '/dashboard/admin/copy-logs'
@@ -579,12 +579,12 @@ export interface FileRouteTypes {
| '/blog_/$slug'
| '/dashboard/billing'
| '/dashboard/blog'
| '/dashboard/changelog'
| '/dashboard/contact'
| '/dashboard/history'
| '/dashboard/search'
| '/dashboard/service-test'
| '/dashboard/settings'
| '/dashboard/uzmanlar'
| '/dashboard/'
| '/dashboard/admin/analytics'
| '/dashboard/admin/copy-logs'
@@ -712,13 +712,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardIndexRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/uzmanlar': {
id: '/dashboard/uzmanlar'
path: '/uzmanlar'
fullPath: '/dashboard/uzmanlar'
preLoaderRoute: typeof DashboardUzmanlarRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/settings': {
id: '/dashboard/settings'
path: '/settings'
@@ -754,6 +747,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardContactRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/changelog': {
id: '/dashboard/changelog'
path: '/changelog'
fullPath: '/dashboard/changelog'
preLoaderRoute: typeof DashboardChangelogRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/blog': {
id: '/dashboard/blog'
path: '/blog'
@@ -988,12 +988,12 @@ const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
interface DashboardRouteChildren {
DashboardBillingRoute: typeof DashboardBillingRoute
DashboardBlogRoute: typeof DashboardBlogRoute
DashboardChangelogRoute: typeof DashboardChangelogRoute
DashboardContactRoute: typeof DashboardContactRoute
DashboardHistoryRoute: typeof DashboardHistoryRoute
DashboardSearchRoute: typeof DashboardSearchRoute
DashboardServiceTestRoute: typeof DashboardServiceTestRoute
DashboardSettingsRoute: typeof DashboardSettingsRoute
DashboardUzmanlarRoute: typeof DashboardUzmanlarRoute
DashboardIndexRoute: typeof DashboardIndexRoute
DashboardAdminAnalyticsRoute: typeof DashboardAdminAnalyticsRoute
DashboardAdminCopyLogsRoute: typeof DashboardAdminCopyLogsRoute
@@ -1021,12 +1021,12 @@ interface DashboardRouteChildren {
const DashboardRouteChildren: DashboardRouteChildren = {
DashboardBillingRoute: DashboardBillingRoute,
DashboardBlogRoute: DashboardBlogRoute,
DashboardChangelogRoute: DashboardChangelogRoute,
DashboardContactRoute: DashboardContactRoute,
DashboardHistoryRoute: DashboardHistoryRoute,
DashboardSearchRoute: DashboardSearchRoute,
DashboardServiceTestRoute: DashboardServiceTestRoute,
DashboardSettingsRoute: DashboardSettingsRoute,
DashboardUzmanlarRoute: DashboardUzmanlarRoute,
DashboardIndexRoute: DashboardIndexRoute,
DashboardAdminAnalyticsRoute: DashboardAdminAnalyticsRoute,
DashboardAdminCopyLogsRoute: DashboardAdminCopyLogsRoute,

View File

@@ -31,6 +31,7 @@ import { Link, Outlet, createFileRoute, useNavigate, useRouterState } from "@tan
import {
BarChart3,
BookOpen,
CalendarDays,
ChevronsUpDown,
Copy,
CreditCard,
@@ -50,7 +51,6 @@ import {
Shield,
Sparkles,
Sun,
Trophy,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
@@ -72,7 +72,6 @@ const mainMenuItems: readonly NavItem[] = [
{ to: "/dashboard/search", labelKey: "nav.search", icon: Search },
{ to: "/dashboard/catalog", labelKey: "nav.catalog", icon: Library },
{ to: "/dashboard/history", labelKey: "nav.history", icon: History },
{ to: "/dashboard/uzmanlar", labelKey: "nav.experts", icon: Trophy },
];
const accountItems: readonly NavItem[] = [
@@ -82,6 +81,7 @@ const accountItems: readonly NavItem[] = [
];
const supportItems: readonly NavItem[] = [
{ to: "/dashboard/changelog", labelKey: "nav.changelog", icon: CalendarDays },
{ to: "/dashboard/contact", labelKey: "nav.contact", icon: Mail },
{ to: "/dashboard/blog", labelKey: "nav.blog", icon: BookOpen },
];

View File

@@ -0,0 +1,14 @@
import { ChangelogTab } from "@/components/settings/changelog-tab";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/dashboard/changelog")({
component: ChangelogPage,
});
function ChangelogPage() {
return (
<div className="mx-auto max-w-3xl">
<ChangelogTab />
</div>
);
}

View File

@@ -1,5 +1,3 @@
import { OemSuggestionsSection } from "@/components/catalog/oem-suggestions-section";
import { OemVoteCard } from "@/components/catalog/oem-vote-card";
import { api } from "@/lib/api-client";
import { capture } from "@/lib/posthog";
import { Badge, Button, Input, Skeleton } from "@sase/ui";
@@ -195,9 +193,6 @@ function OemDetailPage() {
</div>
</header>
{/* ─── Topluluk uyumluluk oyu (P eşleşmesinden bağımsız) ───────────── */}
<OemVoteCard oemCode={code} />
{/* ─── Loading ────────────────────────────────────────────────────── */}
{isLoading && (
<div className="space-y-4">
@@ -226,8 +221,7 @@ function OemDetailPage() {
<p className="font-medium">Bu OEM kodu için uyumlu parça bulunamadı</p>
<p className="max-w-md text-sm text-muted-foreground">
Bağlantı parçaları, klipsler ve bazı orijinal kodların muadili henüz kataloğumuzda
olmayabilir. Katalog büyüdükçe eşleşme oranı artar. Muadilini biliyorsanız aşağıdan
önerin diğer parça satıcılarına yol gösterir.
olmayabilir. Katalog büyüdükçe eşleşme oranı artar.
</p>
</div>
)}
@@ -345,9 +339,6 @@ function OemDetailPage() {
</div>
)}
{/* ─── Topluluk muadil önerileri (eşleşme olmasa da toplanır) ──────── */}
{!isLoading && <OemSuggestionsSection oemCode={code} />}
{/* ─── Reverse catalog: your vehicles that use this code ───────────── */}
{catalogVehicles && catalogVehicles.length > 0 && (
<section className="space-y-3">

View File

@@ -1,135 +0,0 @@
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, ThumbsUp, Trophy } 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;
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">
OEM kodlarını oyla, puan topla, sıralamada yüksel doğru bilgi bütün parça
satıcılarının işini hızlandırır.
</p>
</div>
{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">Sıralama henüz boş.</p>
<p className="max-w-md text-sm text-muted-foreground">
İlk uzman siz olun: katalogdan bir OEM kodunu değerlendirin, ılış puanlarını siz
kapın.
</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} />
</>
)}
{/* Puan kuralları yalnız burada yaşar — küçük punto (computeVoteAward ile aynı kural). */}
<p className="pt-2 text-center text-[11px] leading-relaxed text-muted-foreground/70">
Oy ver <span className="font-semibold text-muted-foreground">+1</span> · çoğunluğu tuttur{" "}
<span className="font-semibold text-muted-foreground">+2 bonus</span> · kodu ilk
değerlendiren <span className="font-semibold text-muted-foreground">3 puanı</span> kapar
</p>
</div>
);
}