polish(web): oy kartı header'a — OEM kodunun sağ simetriğine, araç+parça bağlamıyla

Topluluk oyu kartının koyu dolgusu kalktı (sayfa arkaplanıyla aynı, yalnız
çerçeve); desktop'ta başlıkta OEM kodunun sağına yerleşti, mobilde kodun
altına iner. Katalogdan gelişte araç etiketi + parça adı search-param'la
taşınır (v/p/vid) ve kartta butonların üstünde "araç · parça" satırı olarak
gösterilir — neyle neyin uyumlu olduğu tek alanda. vid oy kaydına analitik
bağlam olarak geri eklendi; doğrudan ziyarette genel soru metnine düşülür.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:47:06 +03:00
parent 6575c611e8
commit 24e668ea01
5 changed files with 138 additions and 68 deletions

View File

@@ -49,7 +49,12 @@ describe("OemVoteCard", () => {
votes: { "8V0615423E": { compatible: 4, incompatible: 1, myVote: "compatible" } },
});
render(<OemVoteCard oemCode="8V0615423E" />);
render(
<OemVoteCard oemCode="8V0615423E" vehicleLabel="Audi A4 (2018)" partName="Fren balatası" />,
);
// bağlam satırı: neyle neyin uyumlu olduğu tek alanda
expect(screen.getByText("Audi A4 (2018) · Fren balatası")).toBeInTheDocument();
const upButton = screen.getByRole("button", { name: /Uyumlu/ });
const downButton = screen.getByRole("button", { name: /Uyumsuz/ });
@@ -84,10 +89,10 @@ describe("OemVoteCard", () => {
fireEvent.click(downButton);
await waitFor(() => {
expect(postMock).toHaveBeenCalledWith("/oem-votes", {
oemCode: "X1",
vote: "incompatible",
});
expect(postMock).toHaveBeenCalledWith(
"/oem-votes",
expect.objectContaining({ oemCode: "X1", vote: "incompatible" }),
);
expect(toastSuccessMock).toHaveBeenCalledWith("+3 puan kazandınız!", expect.anything());
expect(captureMock).toHaveBeenCalledWith(
"oem_vote_cast",

View File

@@ -3,7 +3,7 @@ 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 { Car, ThumbsDown, ThumbsUp } from "lucide-react";
import { useEffect, useState } from "react";
export type OemVoteChoice = "compatible" | "incompatible";
@@ -23,15 +23,34 @@ export interface CastOemVoteResult {
isNew: boolean;
}
interface OemVoteCardProps {
oemCode: string;
// Katalogdan gelişte taşınan bağlam: "neyle neyin uyumlu olduğu" tek alanda
// görünsün diye butonların üstünde araç + parça adı gösterilir. Doğrudan
// ziyarette (bağlam yok) genel soru metnine düşülür.
vehicleLabel?: string;
partName?: string;
vehicleId?: string;
className?: string;
}
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 }) {
// OEM detay başlığındaki topluluk oyu kartı: uyumlu/uyumsuz + sayaçlar.
// Arkaplanı sayfayla aynı (dolgu yok, yalnız çerçeve). Puan kuralı yalnız
// Parça Uzmanları sayfasında yazılıdır; burada toast geri bildirimi yeter.
export function OemVoteCard({
oemCode,
vehicleLabel,
partName,
vehicleId,
className,
}: OemVoteCardProps) {
const [summary, setSummary] = useState<OemVoteSummary | null>(null);
const [pending, setPending] = useState(false);
const context = [vehicleLabel, partName].filter(Boolean).join(" · ");
useEffect(() => {
let cancelled = false;
setSummary(null);
@@ -51,7 +70,11 @@ export function OemVoteCard({ oemCode }: { oemCode: string }) {
const cast = async (vote: OemVoteChoice) => {
setPending(true);
try {
const result = await api.post<CastOemVoteResult>("/oem-votes", { oemCode, vote });
const result = await api.post<CastOemVoteResult>("/oem-votes", {
oemCode,
vote,
vehicleId,
});
setSummary({ ...result.counts, myVote: result.myVote });
if (result.isNew) {
const isFirstVote = result.counts.compatible + result.counts.incompatible === 1;
@@ -71,6 +94,7 @@ export function OemVoteCard({ oemCode }: { oemCode: string }) {
points_awarded: result.pointsAwarded,
correct: result.correct,
is_new: result.isNew,
vehicle_id: vehicleId,
surface: "oem_detail",
});
} catch {
@@ -98,38 +122,43 @@ export function OemVoteCard({ oemCode }: { oemCode: string }) {
];
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>
<section className={cn("rounded-xl border border-border p-4", className)}>
<h2 className="text-sm font-semibold">Topluluk uyumluluk oyu</h2>
{context ? (
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<Car className="size-3.5 shrink-0" />
<span className="truncate" title={context}>
{context}
</span>
</p>
) : (
<p className="mt-1 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 className="mt-3 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 flex-1 items-center justify-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>
<p className="mt-3 text-xs text-muted-foreground">
Her oy puan kazandırır ·{" "}

View File

@@ -157,7 +157,9 @@ describe("PartsPanel", () => {
render(<PartsPanel parts={[buildPart()]} vehicleId="v1" categoryId="c1" />);
const link = screen.getByRole("link", { name: "OEM-1" });
expect(link).toHaveAttribute("href", "/dashboard/oem/OEM-1");
// href bağlam taşır: parça adı + vehicleId (oy kartındaki "araç · parça" satırı)
expect(link.getAttribute("href")).toContain("/dashboard/oem/OEM-1");
expect(link.getAttribute("href")).toContain("vid=v1");
// 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();

View File

@@ -22,6 +22,17 @@ interface PartsPanelProps {
const SKELETON_ROW_KEYS = ["s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7"] as const;
// OEM detay linki, oy kartının "araç · parça" bağlam satırı için görüntü
// bağlamını search-param olarak taşır (v/p/vid; oem.$code validateSearch).
function oemDetailHref(code: string, search: { v?: string; p?: string; vid?: string }) {
const qs = new URLSearchParams();
if (search.v) qs.set("v", search.v);
if (search.p) qs.set("p", search.p);
if (search.vid) qs.set("vid", search.vid);
const tail = qs.toString();
return `/dashboard/oem/${encodeURIComponent(code)}${tail ? `?${tail}` : ""}`;
}
export function PartsPanel({
parts,
vehicleId,
@@ -372,7 +383,11 @@ export function PartsPanel({
// SPA reload); real href kept so ctrl/cmd/middle-click
// still opens a new tab.
<a
href={`/dashboard/oem/${encodeURIComponent(part.oemCode)}`}
href={oemDetailHref(part.oemCode, {
v: vehicleLabel,
p: part.name,
vid: vehicleId,
})}
title="Uyumlu parça kodlarını gör"
className="underline decoration-dotted underline-offset-2 transition-colors hover:text-foreground hover:decoration-solid"
onClick={(e) => {
@@ -388,6 +403,7 @@ export function PartsPanel({
navigate({
to: "/dashboard/oem/$code",
params: { code: part.oemCode },
search: { v: vehicleLabel, p: part.name, vid: vehicleId },
});
}}
>

View File

@@ -116,11 +116,22 @@ function PartThumb({ src, alt }: { src: string | null; alt: string }) {
// ─── Route ───────────────────────────────────────────────────────────────────
export const Route = createFileRoute("/dashboard/oem/$code")({
// Katalogdan gelişte taşınan görüntü bağlamı: v = araç etiketi, p = parça
// adı (oy kartında "neyle neyin uyumlu olduğu" satırı), vid = vehicleId
// (oy kaydına analitik bağlam). Doğrudan ziyarette üçü de boş.
validateSearch: (
search: Record<string, unknown>,
): { v?: string; p?: string; vid?: string } => ({
v: typeof search.v === "string" && search.v ? search.v : undefined,
p: typeof search.p === "string" && search.p ? search.p : undefined,
vid: typeof search.vid === "string" && search.vid ? search.vid : undefined,
}),
component: OemDetailPage,
});
function OemDetailPage() {
const { code } = Route.useParams();
const { v: vehicleLabel, p: partName, vid: vehicleId } = Route.useSearch();
const { data, isLoading, error } = useQuery({
queryKey: ["p-oem", code],
@@ -171,33 +182,40 @@ function OemDetailPage() {
return (
<div className="mx-auto max-w-4xl space-y-6">
{/* ─── Header ─────────────────────────────────────────────────────── */}
<header className="flex items-start gap-4">
<Button asChild variant="ghost" size="icon" className="mt-0.5 shrink-0">
<Link to="/dashboard/search" aria-label="Geri">
<ArrowLeft className="size-5" />
</Link>
</Button>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
OEM kodu
</p>
<div className="mt-1 flex flex-wrap items-center gap-3">
<h1 className="font-mono text-2xl font-bold tracking-tight break-all">{code}</h1>
<CopyCode
code={code}
className="rounded-md border border-border px-2 py-1 hover:bg-accent"
/>
{/* ─── Header: solda OEM kodu, desktop'ta sağ simetriğinde topluluk
oyu kartı; mobilde kart kodun altına iner ─────────────────────── */}
<header className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex min-w-0 flex-1 items-start gap-4">
<Button asChild variant="ghost" size="icon" className="mt-0.5 shrink-0">
<Link to="/dashboard/search" aria-label="Geri">
<ArrowLeft className="size-5" />
</Link>
</Button>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
OEM kodu
</p>
<div className="mt-1 flex flex-wrap items-center gap-3">
<h1 className="font-mono text-2xl font-bold tracking-tight break-all">{code}</h1>
<CopyCode
code={code}
className="rounded-md border border-border px-2 py-1 hover:bg-accent"
/>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Uyumlu parça kodları ve muadil numaralar
</p>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Uyumlu parça kodları ve muadil numaralar
</p>
</div>
<OemVoteCard
oemCode={code}
vehicleLabel={vehicleLabel}
partName={partName}
vehicleId={vehicleId}
className="w-full lg:w-96 lg:shrink-0"
/>
</header>
{/* ─── Topluluk uyumluluk oyu (P eşleşmesinden bağımsız) ───────────── */}
<OemVoteCard oemCode={code} />
{/* ─── Loading ────────────────────────────────────────────────────── */}
{isLoading && (
<div className="space-y-4">