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 ·{" "}